Skip to content

feat(installer): Avalonia GUI wizard, CLI, and Velopack packaging - #59

Merged
Zaldaryon merged 59 commits into
mainfrom
feat/avalonia-installer
Aug 28, 2026
Merged

feat(installer): Avalonia GUI wizard, CLI, and Velopack packaging#59
Zaldaryon merged 59 commits into
mainfrom
feat/avalonia-installer

Conversation

@Zaldaryon

Copy link
Copy Markdown
Collaborator

What this does

Lands the Optimum installer stack on main. None of it is on main today.

  • Optimum.Bootstrap.Core: the engine. Prerequisite scan and acquisition, client download and decompile, patch and build, transactional deploy with rollback, shortcut writing, registered uninstall.
  • Optimum.Cli: the optimum verb interface over Core, with a consent gate and an NDJSON progress stream.
  • Optimum.Installer: an Avalonia 12 / SukiUI wizard driving the same Core engine. Prerequisite checklist with in-place tool install, install-location and data-folder choices, a live build log, and a completion screen that launches the game and closes the installer.
  • Velopack packaging and self-update for win-x64 and linux-x64. osx-arm64 and osx-x64 are built for archival, not published.
  • ci-installer.yml builds and tests the Optimum.Installer.slnf scope and runs a Velopack pack smoke test. release-installer.yml is workflow_dispatch: it packs, renames the artifacts to Optimum-v<version>-<rid>-Installer.<ext>, and publishes the feed.

INSTALLER-PLAN.md tracks the phase history. The branch also carries the Windows install-path fixes (per-OS prerequisite scan, PowerShell resolution, in-place SDK and ilspycmd executors), the move from the earlier design system to stock SukiUI 7.0.1, bundled Lexend and JetBrains Mono, a cyan theme, and a UX review pass.

Testing

dotnet test Optimum.Installer.slnf -c Release: 230 pass (134 Core, 19 CLI, 77 GUI). The linux-x64 AppImage was built and launched under WSLg. The win-x64 self-contained build was run on Windows and checked against real DirectWrite font rendering.

Not done

Live click-through automation, the consent scroll-tracking render test, and a real per-platform install into a launched game are still open. Nothing is code-signed, so the Windows Setup.exe trips SmartScreen and the macOS .pkg trips Gatekeeper.

Plan to replace the three diverged installer scripts (install-linux.sh,
install-windows.ps1, install-macos.sh) with one C# .NET 10 codebase:
Optimum.Bootstrap.Core (library), Optimum.Cli (NDJSON front end for
RiftLauncher), and Optimum.Installer (Avalonia GUI). Records the engine
contract, the cross-OS gap resolutions, the phased rollout, and the open
questions. No code yet.
EULA text gets rewritten to match LICENSE-SCOPE.md, consent posture B (notice, no hard gate), one legal question still out. macOS distribution deferred: no Apple Developer account yet, so no signed macOS release; install-macos.sh stays. macOS packaging when it ships is a Velopack .pkg, not Parcel. Velopack on .NET 10 confirmed by a local spike for linux-x64 and win-x64 including a delta.
The legal question resolved: local decompilation of the user's own game
needs explicit consent. GUI gates on a checkbox, Optimum.Cli build requires
--acknowledge-decompile (refused as bad-input if absent), RiftLauncher
renders the text and passes the flag. Updates the engine contract, the CI
steps, Phase 2, and Phase 7.
Six projects under a new /Installer/ folder in VintageStory.slnx, built
and tested bootstrap-free via Optimum.Installer.slnf:

- Optimum.Bootstrap.Core: ProgressPhase and FailureReason contract types,
  CoreInfo.Version. CliWrap referenced for the Phase 2 build driver.
- Optimum.Cli (AssemblyName 'optimum'): answers --version, exits 2 with
  usage on anything else. CliRunner split out for testing.
- Optimum.Installer: one-window Avalonia 12.1 app, CommunityToolkit.Mvvm,
  compiled bindings.
- Three test projects, 11 tests, one a headless Avalonia render. Installer
  tests are on xUnit v3 (Avalonia.Headless.XUnit 12.x requires it); the
  other two stay on v2 like the existing test projects.

ci-installer.yml runs the tests and a velopack-smoke job (packs two
versions of Optimum.Cli, asserts the delta builds) on push and PR touching
installer paths. INSTALLER-PLAN.md section 11 Phase 0 marked done.
…ase 1)

Optimum.Bootstrap.Core gains its detection and protocol layer behind an
ISystemProbe seam so tests run against an in-memory host:

- Prerequisites: PrerequisiteScanner over the check-prereqs.sh tool list,
  DotnetSdkProbe (ports check_dotnet10), ConfigFiles (reads the ilspycmd
  pin and range from .config/), IlspycmdVersion range check, NixEnvironment
  (NixOS and non-FHS detection and refusal). pwsh is modelled as
  required-for-packaging, not required, so a Linux tar.gz build is not
  blocked by it.
- Acquisition: SdkAcquisition plans the dotnet-install invocation with
  --jsonfile global.json and refuses on NixOS or a non-FHS host;
  IlspycmdAcquisition emits the exact 'tool update' arguments the shell
  test asserts.
- Paths: InstallPathGuard consolidates guard_install_dir and
  Assert-SafeInstallerPaths plus a symlink-component walk; every section 9
  case is covered.
- DataPathProbe: session-aware data-folder detection, ported from
  prompt_data_path and widened per platform.
- NdjsonWriter: the section 4 stream, with monotonic 0-99 progress and a
  single terminal result enforced.
- Licensing: consent-notice.md rewritten to match LICENSE-SCOPE.md;
  ConsentNotice.AcknowledgeFlag is the CLI's --acknowledge-decompile.

72 Core tests, including the ported install-linux-prerequisites.sh and
install-linux-nixos.sh behaviors. Full suite 76 green.
The synchronous ReadToEnd pair in SystemProbe.Run could deadlock a probe
whose child filled one pipe buffer while the reader blocked on the other.
An adversarial pass against the shell sources found three divergences
worth closing:

- command -v equivalent now checks the execute bit and continues past a
  non-executable file of the right name, matching the shell. Added
  ISystemProbe.IsExecutable; DotnetSdkProbe gates on it.
- InstallPathGuard rejects a symlinked install or data directory itself,
  not any symlinked ancestor. A home or mount point that is a symlink is
  normal and was being refused. The full assertNoSymlinkComponents walk
  stays in SymlinkComponentCheck for paths with a trusted base.
- NdjsonWriter emits a warn log and counts it when it adjusts a caller's
  progress value, so an engine-side miscalculation is not silent.

Also: HasNet10Line anchors at column 0 like the shell grep, and the
ilspycmd version token is trimmed. 79 tests green.
…ase 2)

Core build layer:
- ScriptBuildDriver drives scripts/bootstrap.*, dotnet build VintageStory.slnx,
  and the platform packaging script through CliWrap, one ProgressPhase per step,
  with per-step FailureReason mapping. BootstrapFailureClassifier splits a failed
  bootstrap into patch-conflict vs decompile-failed.
- Capabilities reads forks.json and the patches-*-bridge dirs; PackageLayout,
  InstallManifest, PackageDeployer (guard + copy + manifest, non-transactional;
  the stage/backup/rollback is Phase 4), Uninstaller (manifest-driven), and a
  header-only RuntimeValidator.

Optimum.Cli: preflight, build, install, validate, uninstall, capabilities,
--version. build requires --acknowledge-decompile. --json emits the section 4
NDJSON stream via NdjsonWriter; plain text otherwise. SIGTERM/SIGINT cancel a
build to a cancelled result. EngineOutput bridges the two modes.

Tests: 12 CLI tests including the NDJSON contract check (NdjsonStream, twin of
scripts/check-ndjson-stream.py), patch-conflict and cancelled reasons, the
consent gate, and path-absoluteness. 95 Core tests (deploy/uninstall round trips
on real temp dirs).

CI: ci-installer.yml gains a cli-contract job; ci-platform-bootstrap.yml's
linux job runs Optimum.Cli build end to end plus validate, timeout raised to 60m.

109 tests green.
Adversarial review of the CLI and build driver surfaced:

- macOS build always failed: LocatePackage only looked for an Optimum-v*
  directory, but package-macos.sh produces Optimum.app. Now per-platform.
- SIGTERM cleanup deleted the whole --output tree. The output guard now
  refuses any non-empty directory (files or subdirs), and cancellation
  removes only what the build wrote, keeping a pre-existing empty dir.
- --client-archive was not forwarded to the packaging step, so it
  re-downloaded ~500 MB. Now forwarded to bootstrap and packaging both.
- Windows build injected -ClientArchive __skip__ when no archive was
  given, which makes bootstrap.ps1 throw. Now the flag is omitted so
  bootstrap.ps1 downloads.
- check-patches.sh --strict-unavailable now runs between build and
  package, matching CI; a header-level RuntimeValidator runs on the
  produced package.
- Uninstaller confines manifest entries to the install directory; a
  rooted or ../ entry is refused, not followed.
- install refuses a non-empty target rather than deleting it. In-place
  replace with rollback stays Phase 4.
- Manifest version comes from the Optimum-v<version> package name.
- CopyDirectory uses Path.GetRelativePath, not string replace.
- --repo-root documented in the contract; query verbs (preflight,
  capabilities) documented as single-document, not stream.
- Cancellation test now drives a real cancelled token through CliRunner.

113 tests green.
Optimum.Installer is an Avalonia 12 MVVM app that drives Core in-process.

- MainWindowViewModel: the wizard shell and state machine (Prerequisites,
  Options, a mandatory EULA modal, Progress, Completion). Backward nav only
  Options -> Prerequisites, blocked once Progress starts. Screen view models
  raise the transition they want; the shell decides.
- PrerequisitesViewModel over PrerequisiteScanner, gates Continue on
  BlocksBuild; reports repo-root-missing when run outside a checkout.
- OptionsViewModel: per-platform default install dir, InstallPathGuard on
  every change into an inline error, DataPathProbe pickup, a version
  selector only when Capabilities reports a bridge set.
- EulaViewModel: accept gated on scroll-to-end plus a checkbox.
- ProgressViewModel: its own IBuildObserver; runs ScriptBuildDriver then
  PackageDeployer under one bar with an honest time estimate; filters raw
  subprocess lines through InstallerLogFilter (ported from install-windows.ps1).
- CompletionViewModel: Launch, Try again, View log.
- ViewLocator resolves view models to views; five screen views plus the
  EULA view, minimal Fluent styling.

FakeSystemProbe moved to a plain Optimum.Bootstrap.Core.TestSupport project
so the xUnit v2 and v3 test projects can share it without the v2/v3
FactAttribute collision.

Optimum.Installer.Tests: 27 tests (25 plain xUnit v3 view-model tests, two
Avalonia.Headless render tests), no xvfb. 138 tests green overall.
Adversarial review of the wizard surfaced:

- Launch button opened the .sh launcher in an editor via xdg-open on Linux.
  CompletionViewModel now runs it directly (cmd /c on Windows), with the
  install dir as the working directory.
- The multi-hundred-MB temp build tree was never cleaned. ProgressViewModel
  owns it now and deletes it in a finally after the deploy.
- Elapsed time and ETA froze between driver progress lines. A 1s timer
  ticks the clock independently; the ETA is labelled rough.
- Cancel was a straight kill. IBuildDriver.RunAsync takes a graceful and a
  forceful token; ProgressViewModel.Cancel signals graceful, then forces
  after 10s. CliWrap's two-token ListenAsync overload drives it.
- No re-entrancy guard: a double-click on Accept could race two builds.
  MainWindowViewModel guards with _installStarted.
- Retry kept a stale Options screen. RestartFromPrerequisites rebuilds it
  so DataPathProbe and Capabilities re-run.
- A cancelled run had no retry route. CanRetry covers cancelled too.
- The EULA scroll gate could latch true during layout (Extent 0) or never
  fire for short content. The decision is now a pure ScrollReadGate,
  unit-tested; the flaky giant-window headless test is gone.
- PrerequisiteRowViewModel no longer inherits ViewModelBase, so the
  ViewLocator never tries to resolve a view for it.
- InstallCompletion completes after the screen moves (TaskCompletionSource);
  the raw log is written off the UI thread.

146 tests green (35 installer).
…l (Phase 4)

PackageDeployer is now transactional on all platforms: stage the whole
tree beside the target, move an existing Optimum install to a backup, swap
with one rename, delete the backup, roll back to the previous install on
any failure. A FailAtStep hook drives the rollback tests.

ShortcutWriter writes menu and desktop shortcuts per platform (Linux
.desktop plus a hicolor icon, Windows .lnk via WScript.Shell, macOS a
symlink into ~/Applications), records them in the manifest, and removes
them on uninstall. UninstallRegistration writes and removes the Windows
Optimum_is1 uninstall key.

RuntimeValidator takes section 7 option 2: a MetadataLoadContext over
VintagestoryLib.dll confirms Vintagestory.Client.ClientProgram still has
a static Main, no game code executed.

InstallManifest gains shortcuts and uninstallRegistryKey. 151 tests
green (103 Core).
Adversarial review of the transactional deploy surfaced:

- Rollback failure was swallowed and still reported 'rolled back'. The
  deploy now tracks a committed flag: a pre-swap failure rolls back, a
  post-swap failure keeps the complete install and downgrades the cleanup
  error to a warning, and a rollback that cannot restore the backup is
  reported with the backup's path.
- A failure between the swap and the backup delete no longer routes to a
  rollback that would destroy the good new install.
- RegisterInstall is exception-safe and, if the manifest cannot be
  updated, removes the shortcuts and registry entry it just wrote so
  nothing is orphaned.
- Uninstaller removes each entry independently and always runs the
  shortcut, registry, and .optimum cleanup even past a locked file; a
  manifest with an escaping entry is still refused outright.
- RuntimeValidator moves its enumeration inside the try, tolerates a
  partial GetTypes via ReflectionTypeLoadException.Types, and never fails
  a build it merely could not inspect.
- .desktop Exec values are escaped per the Desktop Entry spec.
- The macOS link points at a real .app bundle (nested or the install dir
  when it carries Contents/Info.plist) and is named accordingly.
- A cross-filesystem Directory.Move (install dir is a mount point) gives
  a clear message instead of a misleading 'rolled back'.

157 tests green (106 Core).
Optimum.Installer runs VelopackApp.Build().Run() first in Main and carries
an IUpdateService that checks the installer's own GitHub release feed
(channel per RID) on startup and shows a non-blocking update banner. It is
a no-op outside a Velopack install, so the wizard never waits on it.

vpk is a repo-local tool. make installer-pack INSTALLER_RID=<rid>
publishes self-contained and packs. release-installer.yml is a manual
release that packs win-x64, linux-x64, and osx-arm64, uploads all three,
and publishes only Windows and Linux; Windows signing is wired to a
secret that is not set yet.

ci-installer.yml velopack-smoke now packs the real Optimum.Installer for
linux-x64 and checks the AppImage and a delta. Local spike: 48 MB
AppImage, 51 KB delta. 157 tests green (38 installer).
Adversarial review of the Velopack work surfaced:

- The self-update banner was live on every screen, including a running
  build; 'Update now' would ApplyUpdatesAndRestart mid-install and orphan
  the build subprocess. The banner is now gated to Prerequisites and
  Options (UpdatePromptVisible).
- The release matrix had every platform job publish to the same GitHub
  tag in parallel, racing the API into a half-release. Publishing moved
  to a single 'publish' job that needs the matrix and uploads one channel
  at a time.
- vpk upload omitted --outputDir, defaulting to a different (empty)
  directory on the case-sensitive runner. It is passed now.
- The Windows signing 'if:' referenced a step-level env var it could not
  see; the secret is a job-level env var now.
- UpdateService: _pending guarded by a lock and snapshot once in
  ApplyAsync; CheckAsync rethrows a real cancellation instead of
  swallowing it and narrows the catch.
- The banner shows a progress bar while updating and an error line on
  failure.

158 tests green.
… (Phase 6)

- LICENSE-SCOPE.md lists all six installer projects, the test-support
  project, Optimum.Installer.slnf, and INSTALLER-PLAN.md under MIT.
- README.md leads with a graphical-installer section and a command-line
  section; the per-platform scripts stay below as the original path.
- CONTRIBUTING.md documents make installer-test and make installer-pack.
- scripts/uninstall.sh is rewritten: it delegates to 'optimum uninstall'
  for a manifest-based install (which the old script could not touch) and
  keeps the legacy overlay removal as a fallback.
- install-linux.sh and install-windows.ps1 carry a notice pointing at the
  maintained path but stay functional; the shim swap waits for a green
  Optimum.Cli build on every platform CI job.

158 tests green.
Clone the matching Optimum source over HTTPS into a per-version user cache when the standalone installer has no repository root. Wire the resolved root through the GUI and expose the same path through the CLI.

Use a verified staging checkout and preserve the previous cache until promotion succeeds. Cover cache selection, clone arguments, failure reporting, CLI integration, and the complete wizard handoff.

Verification: 186 installer tests pass; Core, CLI, and Installer build with warnings as errors; prerequisite, patch, runtime donor, shader, real shallow clone, and AppImage payload checks pass.
Replaces the EULA modal with a four-step wizard flow (System check,
Install options, Review and consent, Install) and a persistent step
header, from the ui-ux-pro-max review. Cancel now confirms; the
technical log is collapsed by default.

Unfinished checkpoint: flow and render tests are green at 49 GUI tests,
but the visual pass on the reworked screens and the AppImage rebuild
plus use test are still pending. Pushed so the work can continue on
another machine.
Completes the checkpoint from ed11c9c: the visual pass and the
correctness fixes the reworked flow surfaced.

Correctness:
- ProgressViewModel no longer disposes its cancellation sources while
  the Cancel button is still live. A late click after the run finished
  threw ObjectDisposedException; the 10s forced-cancel escalation
  faulted every time a cancel succeeded. Both are guarded by a
  _finished flag and a try/catch, and the sources are disposed only
  after the Progress view is swapped out.
- The update banner hides when dismissed (MainWindow gated the outer
  Border on UpdatePromptVisible, which ignored Dismissed) and no longer
  shows on the Review screen, matching the Phase 5 note.
- Stepping back to Prerequisites and forward again keeps the Options
  choices instead of rebuilding the view model.
- A second EULA accept keeps the in-flight install task as the
  awaitable rather than replacing it with a completed one.
- The detected data folder is prefilled but not opted into; the default
  shares Vintage Story's data, which the adjacent copy already says.
- The remaining-time estimate waits for a few seconds of real elapsed
  time before showing, so the first frame is not "about 0:00 left".

Strata design system (App.axaml): paper/ink palette with an
oxidised-copper accent and moss/clay status colours in both themes;
Spectral (SIL OFL, bundled) for step titles, Inter for body, a
monospace face for the log; primary/secondary/ghost button tiers on a
low corner radius; Fluent checkbox and text-field accents remapped onto
the copper. A left step rail shows the four layers with the current one
lit and finished ones checked; the content header resolves away on
Completion so the rail carries the finished state. Prerequisites folds
every ready tool into one summary line. Progress shows an
always-visible, auto-scrolling log pane. Completion and the failure
state are full-bleed rather than a floating card.

Tests: 55 GUI tests (was 49) - added the late-cancel no-op, the mid-run
cancel with no unobserved exception, Options choices surviving a step
back, the rail step states, and the banner dismiss / not-on-Review
cases. The consent ScrollChanged wiring stays a manual check.
Verified: 201 tests green (127 Core, 19 CLI, 55 GUI); the packaged
linux-x64 AppImage launches and renders with the bundled font and every
converter resolved.
…ows-aware

The Core layer treated the bash pipeline's tool list and the Linux non-FHS
check as universal, so a Windows host was permanently blocked:

- NixEnvironment: the NixOS and glibc-interpreter checks now short-circuit on
  non-Linux, so a leftover NIX_STORE or a resolved C:\lib64\... path can no
  longer make Windows look non-FHS. This alone reaches the existing (dead)
  Windows dotnet-install plan in SdkAcquisition.
- PrerequisiteScanner: the definition table is now a function of probe.Os.
  Windows requires only the .NET SDK, Git, and PowerShell -- bootstrap.ps1
  reimplements the perl/python3 fixups natively -- and drops the Unix-only
  rows instead of showing them as missing. Git and PowerShell missing on
  Windows route to their download pages.
- PowerShellHost: new resolver, pwsh with a powershell.exe 5.1 fallback on
  Windows, used by the scanner row and the build driver.
- ScriptBuildDriver: spawns the resolved PowerShell instead of a bare `pwsh`,
  skips check-patches.sh on Windows (no PowerShell port; the only stock bash
  is WSL and would run against the wrong filesystem), and catches a spawn
  failure in RunStep so it fails the build instead of escaping unobserved and
  hanging the wizard on the Progress screen.
- DotnetSdkProbe: split OPTIMUM_DOTNET_CANDIDATES on the simulated platform's
  separator, not the host's.

Adds Windows-shaped test coverage across all four areas.
…pping

- Button: add HorizontalContentAlignment/VerticalContentAlignment=Center. The
  Fluent template left ContentControl's Stretch default, so the single-line
  label stretched to the padded box and rendered top-aligned, ~1.5-2.5 px
  above centre on every button.
- Button.accent: drop the BorderThickness=0 override. The presenter already
  paints the border in the accent colour, so appearance is unchanged, but the
  0 px border made the accent content box 1 px shorter than the outlined
  tiers, misaligning adjacent buttons in every footer by 1 px.
- TextBlock: move LineHeight=20 off the universal selector onto .muted (the
  copy that actually wraps). A LineHeight below Spectral SemiBold's ~1.52 em
  natural line box made Avalonia clip the descender and drop the top leading,
  so the page heading's "y" painted into the description below it. .display
  gets an explicit LineHeight of 42; the build-log line gets 16.
SdkAcquisition and IlspycmdAcquisition were planners with no runner, so the
GUI could never act on an Automatic prerequisite that was not appimagetool.

- AcquisitionProcess: shared CliWrap runner that streams into an
  IBuildObserver and turns a spawn failure into a negative exit code with a
  message rather than an exception.
- SdkInstaller (ISdkAcquisition): downloads the official dotnet-install
  script over HTTPS and runs it with the plan's --install-dir/--no-path/
  --jsonfile flags, then verifies with DotnetSdkProbe.Find. Never touches
  PATH. The script fetch is a test seam.
- IlspycmdInstaller (IIlspycmdAcquisition): runs `dotnet tool update -g
  ilspycmd --version <pin> --allow-downgrade` through the discovered dotnet,
  pin from .config/dotnet-tools.json.
- SdkAcquisition: the Windows plan resolves pwsh/powershell.exe instead of a
  bare `pwsh`.
…list

The Prerequisites screen only had an Install button for appimagetool, and
the row list sat in an Auto grid row with no scroller, so on a bare machine
the Continue button rendered off the bottom of the window -- the install was
unreachable even once every tool was in place.

- PrerequisitesView: the source card, attention rows, and ready summary now
  live in a ScrollViewer that takes the middle grid row; the summary header
  and the Continue footer stay pinned. A Manual row shows its command inline
  in a SelectableTextBlock.
- PrerequisitesViewModel: a row's action is resolved from its
  AcquisitionKind -- Automatic dispatches to the SDK / ilspycmd / appimagetool
  service by id, DownloadPage opens the browser ("Get it"), Manual/None get
  no button. A successful install rescans. InstallerServices.CreateReal now
  builds the two new executors; MainWindowViewModel passes them through.
- ProgressViewModel: a catch-all around build-and-deploy routes every
  failure to the completion screen so no exception can strand the wizard on
  the Progress screen.
- OptionsView: Browse buttons for the install and data folders via
  StorageProvider.OpenFolderPickerAsync.
- csproj: drop the Avalonia.Diagnostics reference -- it has no 12.x build, so
  a Debug restore failed with NU1102.

Adds a render test that the Continue button stays on screen when the row
list overflows, and Windows-shaped view-model coverage for the SDK install
and the Git download-page actions.
Rendered captures showed two things the earlier alignment pass missed:

- Text was drawn with LCD subpixel antialiasing, so every label carried a
  cyan/orange colour fringe -- very visible on white-on-accent buttons.
  RenderOptions.TextRenderingMode=Antialias on the window switches the whole
  tree to grayscale AA.
- Line-box centring still left the ink visibly high, because Inter's line box
  carries descent space the descenderless button labels never use. Button
  padding goes to 16,10,16,6 (2px more top than bottom) to land the ink on
  the optical centre.
Replaces the hand-rolled "Strata" design system, which kept fighting the
Fluent theme on control metrics (button label centring, text fringing,
inconsistent spacing). SukiUI ships tuned control themes, a token system,
and the controls a wizard wants.

- csproj: SukiUI 7.0.1 in, Avalonia.Themes.Fluent out. SukiUI pulls
  Avalonia.Themes.Simple, Skia, DataGrid, ColorPicker.
- App.axaml: <suki:SukiTheme ThemeColor="Orange"> replaces FluentTheme and
  the whole Strata ResourceDictionary. One tweak: GlassCard radius 10, no
  hover animation.
- MainWindow is now a SukiWindow (custom chrome, Optimum logo) with a
  suki:VerticalStepper rail bound to the wizard step, and a
  SukiTransitioningContentControl for the step content. MainWindowViewModel
  gains StepIndex / StepLabels for the stepper.
- All six views reworked onto GlassCard, SukiUI text classes (h3/h5/Caption/
  Primary/Warning/Danger), and the Flat / Basic button classes. The build
  log collapses into an Expander; the % readout is a plain h2.
- InstallerConverters resolves against SukiUI's Suki* colour resources.
- RenderOptions.TextRenderingMode=Antialias stays, on the SukiWindow.

Strata's Spectral font is removed with it. 212 tests green.
…ype scale

Three OFL 1.1 faces ship in Assets/Fonts with their licences copied beside
the executable:

- Figtree      body and controls
- Fraunces 72pt page titles and the progress percentage (DisplayFont)
- Geist Mono   the build log (MonoFont)

The default family is set through FontManagerOptions (Segoe UI then Inter as
fallbacks); Avalonia.Fonts.Inter and .WithInterFont() are dropped.

App.axaml carries an accessibility-minded scale on a 16px base:
16 body / 24 line-height, 14 caption, 13 eyebrow, 18 h5, 28 h3, 34 h2,
15 buttons and inputs at a 40px min height, 13 mono log. Inline sub-14
sizes in the views are lifted to the scale.

TextRenderingMode moves to the non-obsolete TextOptions attached property.
The static weight files each carried a different name-table family (ID 1:
"Figtree", "Figtree Medium", "Figtree SemiBold"), so DirectWrite grouped
them into separate families and "avares://...#Figtree" matched nothing on
Windows even though Skia resolved it fine on Linux. Fraunces happened to
work because one of its files was named exactly "Fraunces 72pt".

- One variable file per family: Figtree-VF and GeistMono-VF (single wght
  axis), Fraunces flattened from the 72pt SemiBold static.
- fontTools pass forces a consistent family name across name IDs 1/2/4/16/17/
  21/22 and a 400 default weight, so Skia and DirectWrite agree.
- Reference by explicit file URI (".../Figtree-VF.ttf#Figtree"), not a folder
  URI, and add FontFamily on the SukiWindow as a second anchor.
- Licences copied per family into licences/.
Bumps every size for readability:

- body 18 / line-height 29, secondary copy 16, eyebrow 15, section title
  21, page title 33 (Fraunces), progress readout 42
- buttons, text boxes, combo boxes and checkboxes at 17px with a 44px
  minimum height (touch-target guidance)
- monospace log 15
- window 980x820 (min 900x720), wider content column and gutters

Nothing readable now drops below 15px.
- Body text is Medium weight at full SukiText contrast; the earlier
  Antialias override is dropped so Windows renders it with ClearType, which
  reads noticeably less thin.
- New .lede class (18px, SukiLowText) for the context line under each
  heading; .Caption is pinned to #555 rather than the faint control grey.
- The "N tools ready: ..." summary renders in Geist Mono, since it is a list
  of tool and command names.
… buttons

The global "Button" style set MinHeight 44 and 20,10 padding, which also hit
SukiWindow's minimize/maximize/close buttons -- they stretched vertically
and the minimize dash fell out of line. Scope the style to
Button:not(.WindowControlsButton); SukiWindow sizes those itself.
Drops the custom App.axaml type scale, button sizing, GlassCard tweaks, the
.lede class and the colour overrides. The SukiUI theme now drives sizing,
weight, spacing and colour; Figtree / Fraunces / Geist Mono stay, wired
through SukiUI's DefaultFontFamily resource plus the Avalonia font default.

- Options sections are suki:GroupBox inside suki:GlassCard
- the ready-tools summary and the completion outcome are suki:InfoBar
  (Title + Message; InfoBar has no content presenter, so anything with a
  button -- the update banner, a prerequisite row, the cancel prompt -- is a
  GlassCard instead)
- the progress percentage is suki:WaveProgress
- headings use the h2/h5 classes; Fraunces stays inline on the page title;
  the build log and tool summary keep Geist Mono
- window 960x760

InstallerConverters trimmed to StatusBrush / OutcomeSeverity / LogLevelBrush.
Lexend (a reading-proficiency-tuned OFL face) replaces Figtree and Fraunces
for all UI text; JetBrains Mono replaces Geist Mono for the log and command
lines. Both are single variable files with clean family names.
InfoBar has no way to set the message font, so the ready-tools card goes
back to a GlassCard with a monospace SelectableTextBlock.
SukiUI's 14px base and near-#8a8a8a caption colour were below comfortable
reading contrast. Body goes to 15/1.5, headings a step up, and .Caption uses
SukiLowText (#555, ~7:1) instead of the faint control grey.
…clip

WaveProgress renders from an SKSL shader and fell back to a solid square
where the shader was unavailable. suki:CircleProgressBar is a plain Arc, so
it draws everywhere; the percentage sits in the middle.

Also gives the h2/h3/h5 heading styles their own line height -- the 22px
body value from the previous commit was clipping their caps.
It was a bottom-of-form InfoBar whose Title truncates. Now it is a wrapping
Danger line directly under the install-location box, announced assertively.
… heading

It was a small InfoBar floating in a vertically-centred, left-clamped column
with no title, and the InfoBar showed an info glyph for a success. Now it
matches every other step: a coloured outcome badge, an h2 headline, the
subtext, the install-path card, then the actions.
Both directory boxes relied on placeholder-only labelling, which vanishes on
input. Each now has a short label above it, so the data-folder box in
particular keeps its context.
Every section was a suki:GroupBox (own header rule) nested in a suki:GlassCard
(own border) -- a box in a box. Sections are now a GlassCard with an h5
title, matching SukiUI's own dashboard pattern.
…nstall

StepIndex was pinned at 3 on the completion screen, so the rail kept step 4
"current". It now points past the last step when the install succeeded.
When Windows has client-area animations turned off, the wizard swaps its
fading SukiTransitioningContentControl for a plain ContentControl so step
changes are instant.
…psed strip

The log was a collapsed Expander with a large empty gap beneath it. It is now
a card that fills the remaining space, so build output is visible as it
streams.
SukiUI's VerticalStepper reveals a description slot on the active item, which
reflows its label vertically every time the step changes. The rail is now a
plain ItemsControl over the wizard steps with fixed 48px rows: a filled
accent circle (or a check) for reached steps, a hollow outline for pending
ones, and the label in a fixed position.
Replaces the stock SukiUI orange with a SukiColorTheme built from the logo:
the teal gem (#009E7F) as the primary that buttons, the step rail and links
run on, and the gear gold (#C8A84B) as the accent. The window icon and the
title-bar logo are now the actual Optimum mark.
… came on

ilspycmd, git and dotnet write progress and advisories to stderr, so the log
painted every "not using the latest version" line red. InstallerLogFilter
gains Classify(line, fromStdErr): a real failure is error, a version /
"warning" advisory is warn (yellow), the rest is info.
…rast

Both long-text areas blended into their card. They now sit on a dedicated
InsetSurface brush (a step darker in dark mode, a step lighter... darker grey
in light) with a top hairline, so they read as a distinct document region.
GlassCard corner radius also drops to 12 -- at 20 the rounding was clipping
the last line of a card whose padding was smaller than the radius.
…field shown

Window height to 800, Options card padding and spacing tightened, and the
zero-margin headings from the previous commit together let all three
sections plus the conditional data-folder field sit in view. Also swaps the
SukiUI VerticalStepper leftovers... (window min height raised to match).
…ible log

The build log was a wall of red CS0618 "obsolete" and NETSDK1086 lines --
noise from decompiling and recompiling someone else's code, none of it a
blocker. The visible pane now shows only real failures (red), the decompiler
"you are not using the latest version" advisory (yellow), and progress
markers; the rest stays in the saved raw log. Failure and advisory matching
is much tighter (no more "0 Error(s)" or a stray "error" word turning a line
red).
Clicking "Launch Optimum" now:
- flips the button to a disabled spinner ("Launching Optimum...")
- starts the launcher, then waits for the game to put a window up (polls the
  Optimum / Vintagestory process for a main window handle on Windows; a short
  fixed wait elsewhere; 45s ceiling either way)
- asks the shell to Shutdown() once the window is there

CompletionViewModel raises ExitRequested; MainWindowViewModel forwards it;
App wires it to desktop.Shutdown().
The phase title, status line and the "runs locally / elapsed" row were split
across two grid rows while the ring spanned both, so the text sat with more
space above it than below. They are now a single StackPanel centred against
the ring in a symmetric-padding card. The elapsed clock uses the monospace
face so its width does not jitter each second.
The label column was a star width, so every row stretched to the card edge
and left a big empty margin right of the text while the circle sat tight to
the left. Each row is now a fixed 104px (circle + widest label) and the list
is centred in a narrower rail, so the gap left of the circle and the gap
right of the label are about equal.
…lour

Bundles Assets/optimum-grain.png -- a 128px tileable film grain with ~6%
baked alpha, neutral so it sits over both the light and the dark SukiUI
background. A hit-test-invisible Border tiles it behind the whole shell.
…gh them

The grain ImageBrush sits behind the whole window. SukiUI GlassCards and
InfoBars default to a translucent fill, so the texture showed through every
card as well as the background. Force IsOpaque on both so the grain reads
only on the window surface and rail gutter, not on the content cards.
…grey

SukiUI's opaque GlassCard key is a flat neutral grey (#333333 dark /
#fcfcfc light). The Optimum primary tints the window base a warm olive, so
neutral cards read as the wrong colour sitting on it. Override
SukiGlassCardOpaqueBackground per theme with the colour a translucent
GlassCard composites to over the flat surface (#393926 dark, #FAFAFA
light) - same hue as the background, one step lifted. Drop the unused
suki|InfoBar opaque style.
The teal primary (#009E7F) read as green, and SukiUI derives the window
background from it with an algorithm that skews it further. Switch to a
deep cyan primary (#0B7C97) + brighter cyan accent (#22A5C2), and stop
leaning on SukiUI for the surfaces:

- App.axaml sets AppSurfaceBrush, the card fill and the inset panes to
  explicit cyan-slate tones per theme (all lean blue, B > G, so nothing
  reads teal or grey).
- MainWindow paints an opaque AppSurfaceBrush ground, covering SukiUI's
  primary-tinted 'Flat' shader background.
- optimum-grain.png regenerated with a faint cyan bias.
- The completion badge and the 'system tools ready' check now use the
  cyan primary instead of green; warning/error/info stay semantic.
…ler.<ext>

vpk pack has no name-override option, so after packing rename the
user-facing files (Setup, Portable, AppImage) to the Optimum scheme and
patch assets.<channel>.json so vpk upload still finds them. The .nupkg and
the releases.<channel>.json / RELEASES-<channel> feed files keep their
Velopack names - the updater fetches those by name and users never see
them. 'Installer' in the name keeps these distinct from the ready-to-run
game packages (Optimum-v<version>-<rid>.{zip,AppImage,dmg}).
@Zaldaryon
Zaldaryon merged commit bdb2451 into main Aug 28, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant